feat: consolidate 2FA settings into User Settings page#8210
feat: consolidate 2FA settings into User Settings page#8210
Conversation
Move all two-factor authentication configuration out of the legacy System Settings page and into the admin User Settings panel at /admin/system/users, where it is more contextually appropriate. Security improvements: - Password-type config values are never returned from the API GET endpoint (returns empty string instead) - API SET endpoint guards against overwriting password config with an empty value - Password fields in the settings panel never fetch or display their current value — always rendered blank with "leave blank to keep existing" placeholder - sTwoFASecretKey gets a "Generate" button to fill a fresh 64-char hex key without ever exposing the existing one Other fixes: - getIsTwoFactorAuthSupported() and system warning now also trigger when bRequire2FA is set (not only bEnable2FA) - mapConfigTypeToSettingType() now correctly maps 'password' type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The panel was titled "User Settings" while the toggle button reads "Quick Settings" — update the panel title to match. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR consolidates Two-Factor Authentication (2FA) settings into the User Settings admin panel (/admin/system/users), removing the dedicated "Two-Factor Authentication" tab from the legacy System Settings page. It adds security safeguards preventing password-type config values from being exposed via the API, adds a "Generate" button for creating the 2FA encryption key, and fixes two bugs: getIsTwoFactorAuthSupported() now also returns true when 2FA is required (not just when it's enabled), and mapConfigTypeToSettingType() now correctly maps password type.
Changes:
- Password-type config values are now protected in the GET API (returns
{value: ''}) and guarded against empty-value overwrites in the POST API - The settings panel JS is updated to never fetch/display password values and to generate random secret keys on demand
- 2FA-related logic (
getIsTwoFactorAuthSupported, secrets warning) is updated to trigger onbRequire2FAin addition tobEnable2FA
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
webpack/system-settings-panel.js |
Adds password type rendering (blank, no value fetch, generate button) and wires up the Generate button handler with a crypto.getRandomValues hex generator |
src/admin/routes/api/system/system-config.php |
Adds password-type guard on GET (returns empty) and POST (no-op on empty value) |
src/ChurchCRM/dto/SystemConfig.php |
Removes 2FA category from legacy settings, adds generate flag for sTwoFASecretKey, maps password config type to settings panel |
src/ChurchCRM/Service/UserService.php |
Adds sTwoFASecretKey to the user settings config list |
src/ChurchCRM/Service/AdminService.php |
Warning for missing secrets now also triggers when bRequire2FA is set |
src/ChurchCRM/Authentication/AuthenticationProviders/LocalAuthentication.php |
getIsTwoFactorAuthSupported() now returns true when bRequire2FA is set |
|
|
||
| return SlimUtils::renderJSON($response, ['value' => SystemConfig::getValue($configName)]); | ||
| // Never overwrite a password with an empty value | ||
| if (SystemConfig::getConfigItem($configName)->getType() === 'password' && empty($value)) { |
There was a problem hiding this comment.
Same issue as in getConfigValueByNameAPI: SystemConfig::getConfigItem($configName) may return null for an unknown config name (PHP accesses a non-existent array key), and calling ->getType() on null will produce a fatal PHP error. A null check should be added before dereferencing the result.
| function getConfigValueByNameAPI(Request $request, Response $response, array $args): Response | ||
| { | ||
| return SlimUtils::renderJSON($response, ['value' => SystemConfig::getValue($args['configName'])]); | ||
| $configName = $args['configName']; | ||
| // Never return password values to the browser | ||
| if (SystemConfig::getConfigItem($configName)->getType() === 'password') { | ||
| return SlimUtils::renderJSON($response, ['value' => '']); | ||
| } | ||
|
|
||
| return SlimUtils::renderJSON($response, ['value' => SystemConfig::getValue($configName)]); | ||
| } | ||
|
|
||
| function setConfigValueByNameAPI(Request $request, Response $response, array $args): Response | ||
| { | ||
| $configName = $args['configName']; | ||
| $input = $request->getParsedBody(); | ||
| SystemConfig::setValue($configName, $input['value']); | ||
| $value = $input['value'] ?? ''; | ||
|
|
||
| return SlimUtils::renderJSON($response, ['value' => SystemConfig::getValue($configName)]); | ||
| // Never overwrite a password with an empty value | ||
| if (SystemConfig::getConfigItem($configName)->getType() === 'password' && empty($value)) { | ||
| return SlimUtils::renderJSON($response, ['value' => '']); | ||
| } | ||
|
|
||
| SystemConfig::setValue($configName, $value); | ||
|
|
||
| return SlimUtils::renderJSON($response, ['value' => '']); |
There was a problem hiding this comment.
The new password-type protection behaviors in the config API are not covered by the existing Cypress test suite, but there are already tests for the /admin/api/system/config endpoint (cypress/e2e/api/private/admin/private.admin.person.initial.style.setting.spec.js). The following behaviors should be tested:
GET /admin/api/system/config/sTwoFASecretKey(orsSMTPPass) returns{value: ''}regardless of what is stored.POST /admin/api/system/config/sTwoFASecretKeywith an empty body returns{value: ''}and does NOT overwrite the existing value.
| SystemConfig::setValue($configName, $value); | ||
|
|
||
| return SlimUtils::renderJSON($response, ['value' => '']); |
There was a problem hiding this comment.
The setConfigValueByNameAPI function now unconditionally returns {value: ''} for all successful saves (both password and non-password types). Previously, it returned the actually saved value via SystemConfig::getValue($configName). This is a subtle breaking change in the API contract for non-password types. While no current callers appear to use the returned value, external API clients or future code might rely on the POST response containing the saved value. Consider returning the saved value for non-password types while continuing to return {value: ''} only for password types.
src/ChurchCRM/dto/SystemConfig.php
Outdated
| // Add a generate button for the 2FA encryption key | ||
| if ($settingName === 'sTwoFASecretKey') { | ||
| $entry['generate'] = true; | ||
| } |
There was a problem hiding this comment.
The getSettingsConfig method in SystemConfig is a generic utility for building settings panel configurations, but it now contains a hardcoded special case for sTwoFASecretKey to inject generate: true. This couples a generic utility to a specific setting name, making it harder to extend (any future password-type setting that needs a generate button would require another special case here).
A cleaner approach would be to either: (a) add a generate metadata flag to the ConfigItem constructor/definition so the getSettingsConfig loop can read it generically, or (b) allow the caller (UserService::getUserSettingsConfig) to inject the generate property after getting the base config. This is a design concern, not a blocking issue.
| return SlimUtils::renderJSON($response, ['value' => SystemConfig::getValue($args['configName'])]); | ||
| $configName = $args['configName']; | ||
| // Never return password values to the browser | ||
| if (SystemConfig::getConfigItem($configName)->getType() === 'password') { |
There was a problem hiding this comment.
In both getConfigValueByNameAPI and setConfigValueByNameAPI, SystemConfig::getConfigItem($configName) is called and its return value is immediately dereferenced with ->getType() without any null check.
getConfigItem() returns self::$configs[$name], which is null when $configName is not a valid config key. This will cause a fatal PHP error (TypeError: Cannot call method on null) at runtime rather than returning a graceful 400/404 HTTP response.
Before this PR, the flow would call SystemConfig::getValue() directly (which throws an \Exception with a meaningful message), and any exception handling in the Slim error middleware would produce a proper error response. Now, the new getType() check introduces a crash path.
A null check before calling ->getType() should be added, returning a 400 or 404 error response if the config item is not found, consistent with how renderErrorJSON is used elsewhere in the codebase.
- Auto-generate sTwoFASecretKey in LoadConfigs.php when 2FA is enabled/required and no key exists — admins never need to touch it - Remove sTwoFASecretKey from all admin UI surfaces (Quick Settings panel, System Settings categories, generate-button hint) - sTwoFASecretKey stays in SystemConfig/DB for persistence but is invisible to the admin - Default bEnable2FA to off — admin must explicitly enable 2FA - Remove now-unreachable AdminService warning for missing secret key - Delete unsupported-2fa.php template; replace with a dashboard redirect via SlimUtils::renderRedirect when 2FA is not supported - Fix SettingsUser.php Cancel button: was navigating to /admin dashboard, now correctly links to /admin/system/users Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, users with valid credentials were hard-denied ('Invalid
login or password') when bRequire2FA was enabled but they had not
enrolled in 2FA — giving them no way to enroll and locking everyone
out of the system.
Now follows the same pattern as forced password change:
- Login succeeds normally
- Every subsequent request redirects to the 2FA enrollment page
- Redirect is suppressed when already on the enrollment page
- Once enrolled, the redirect check passes and normal access resumes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2FA enrollment no longer requires admin opt-in. Every user can always self-enroll via their profile menu. The only admin control is bRequire2FA which mandates enrollment for all users. Changes: - Remove bEnable2FA from SystemConfig, UserService (Quick Settings), and all auth logic — enrollment is always open - getIsTwoFactorAuthSupported() removed — no longer conditional; 2FA routes and the header menu item are always available - LocalAuthentication.authenticate() now redirects to 2FA verification whenever the user has enrolled, regardless of system flags - LoadConfigs.php auto-generates sTwoFASecretKey unconditionally on boot - Header.php: "Manage 2FA" always shown, LocalAuthentication import removed - user-current.php: manage2fa() guard and SlimUtils import removed - users.php: 2FA column always shown; table UX improved (badges, no Total Logins column, dropdown-menu-right alignment) - Add 7.0.2-2fa.sql migration to delete bEnable2FA from config_cfg - Register migration in upgrade.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
A full cleanup of the 2FA settings architecture. The core idea: 2FA is a user security feature — every user should be able to self-enroll, and the system should handle all infrastructure automatically.
Before: 2FA required admins to manually configure an encryption key in
Config.php, enablebEnable2FAto allow enrollment, and a separatebRequire2FAto mandate it. EnablingbRequire2FAhard-blocked all unenrolled users with "Invalid login or password".After: The system auto-generates the encryption key on first boot (stored in DB, never exposed),
bEnable2FAis removed entirely (enrollment is always available to all users), andbRequire2FAis the only admin control. Enabling it lets users log in and redirects them to enrollment on every request until they complete setup.Changes
Core Authentication & Config
LoadConfigs.phpsTwoFASecretKeyunconditionally on boot — DB+ORM fully initialized before generation, persists immediately via PropelSystemConfig.phpbEnable2FA; remove Two-Factor Authentication settings category; addpasswordtype mappingLocalAuthentication.phpgetIsTwoFactorAuthSupported(); 2FA prompt driven by enrollment status alone;bRequire2FAallows login then redirects to/v2/user/current/manage2faon every request until enrolledAdminService.phpUserService.phpbEnable2FAandsTwoFASecretKeyfrom Quick Settings panelAPI & Routes
admin/routes/api/system/system-config.php{value:''}for password-type configs; SET no-ops on empty password valuev2/routes/user-current.phpmanage2fa()guard removed; unused imports cleaned upv2/templates/user/unsupported-2fa.phpUI
Header.phpLocalAuthenticationimport removedadmin/views/users.phpbadge-success/badge-secondarybadges; Failed Logins badge only when >0; dropdown right-alignedsystem-settings-panel.jspasswordtype never fetches/displays value; blank with "Leave blank to keep existing" placeholderSettingsUser.php/admin/system/usersDatabase & Docs
src/mysql/upgrade/7.0.2-2fa.sqlbEnable2FAfromconfig_cfgsrc/mysql/upgrade.jsondocs: secret-keys.mdbRequire2FAforced enrollment flowdocs: faqs.mddocs: administration/index.mdwiki: _Sidebar.mdTest plan
sTwoFASecretKeyis auto-generated in DB on first page loadbRequire2FA: unenrolled users can log in, are redirected to enrollment page, and cannot access other pages until enrolledbRequire2FAon: redirect stops, normal access resumes/admin/system/usersshows onlybRequire2FAands2FAApplicationNamebEnable2FAremoved fromconfig_cfgafter running 7.0.2-2fa.sql🤖 Generated with Claude Code